Skip to content

Virtual File System for Node.js#61478

Draft
mcollina wants to merge 127 commits intonodejs:mainfrom
mcollina:vfs
Draft

Virtual File System for Node.js#61478
mcollina wants to merge 127 commits intonodejs:mainfrom
mcollina:vfs

Conversation

@mcollina
Copy link
Member

@mcollina mcollina commented Jan 22, 2026

A first-class virtual file system module (node:vfs) with a provider-based architecture that integrates with Node.js's fs module and module loader.

Key Features

  • Provider Architecture - Extensible design with pluggable providers:

    • MemoryProvider - In-memory file system with full read/write support
    • SEAProvider - Read-only access to Single Executable Application assets
    • VirtualProvider - Base class for creating custom providers
  • Standard fs API - Uses familiar writeFileSync, readFileSync, mkdirSync instead of custom methods

  • Mount Mode - VFS mounts at a specific path prefix (e.g., /virtual), clear separation from real filesystem

  • Module Loading - require() and import work seamlessly from virtual files

  • SEA Integration - Assets automatically mounted at /sea when running as a Single Executable Application

  • Full fs Support - readFile, stat, readdir, exists, streams, promises, glob, symlinks

Example

const vfs = require('node:vfs');
const fs = require('node:fs');

// Create a VFS with default MemoryProvider
const myVfs = vfs.create();

// Use standard fs-like API
myVfs.mkdirSync('/app');
myVfs.writeFileSync('/app/config.json', '{"debug": true}');
myVfs.writeFileSync('/app/module.js', 'module.exports = "hello"');

// Mount to make accessible via fs module
myVfs.mount('/virtual');

// Works with standard fs APIs
const config = JSON.parse(fs.readFileSync('/virtual/app/config.json', 'utf8'));
const mod = require('/virtual/app/module.js');

// Cleanup
myVfs.unmount();

SEA Usage

When running as a Single Executable Application, bundled assets are automatically available:

const fs = require('node:fs');

// Assets are automatically mounted at /sea - no setup required
const config = fs.readFileSync('/sea/config.json', 'utf8');
const template = fs.readFileSync('/sea/templates/index.html', 'utf8');

Public API

const vfs = require('node:vfs');

vfs.create([provider][, options])  // Create a VirtualFileSystem
vfs.VirtualFileSystem              // The main VFS class
vfs.VirtualProvider                // Base class for custom providers
vfs.MemoryProvider                 // In-memory provider
vfs.SEAProvider                    // SEA assets provider (read-only)

Disclaimer: I've used a significant amount of Claude Code tokens to create this PR. I've reviewed all changes myself.


Fixes #60021

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/single-executable
  • @nodejs/test_runner

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Jan 22, 2026
@avivkeller avivkeller added fs Issues and PRs related to the fs subsystem / file system. module Issues and PRs related to the module subsystem. semver-minor PRs that contain new features and should be released in the next minor version. notable-change PRs with changes that should be highlighted in changelogs. needs-benchmark-ci PR that need a benchmark CI run. test_runner Issues and PRs related to the test runner subsystem. labels Jan 22, 2026
@github-actions
Copy link
Contributor

The notable-change PRs with changes that should be highlighted in changelogs. label has been added by @avivkeller.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section.

@Ethan-Arrowood
Copy link
Contributor

Nice! This is a great addition. Since it's such a large PR, this will take me some time to review. Will try to tackle it over the next week.

*/
existsSync(path) {
// Prepend prefix to path for VFS lookup
const fullPath = this.#prefix + (StringPrototypeStartsWith(path, '/') ? path : '/' + path);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use path.join?

validateObject(files, 'options.files');
}

const { VirtualFileSystem } = require('internal/vfs/virtual_fs');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we import this at the top level / lazy load it at the top level?

ArrayPrototypePush(this.#mocks, {
__proto__: null,
ctx,
restore: restoreFS,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
restore: restoreFS,
restore: ctx.restore,

nit

* @param {object} [options] Optional configuration
*/
addFile(name, content, options) {
const path = this._directory.path + '/' + name;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use path.join?

let entry = current.getEntry(segment);
if (!entry) {
// Auto-create parent directory
const dirPath = '/' + segments.slice(0, i + 1).join('/');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use path.join

let entry = current.getEntry(segment);
if (!entry) {
// Auto-create parent directory
const parentPath = '/' + segments.slice(0, i + 1).join('/');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path.join?

}
}
callback(null, content);
}).catch((err) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}).catch((err) => {
}, (err) => {

Comment on lines +676 to +677
const bytesToRead = Math.min(length, available);
content.copy(buffer, offset, readPos, readPos + bytesToRead);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primordials?

}

callback(null, bytesToRead, buffer);
}).catch((err) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}).catch((err) => {
}, (err) => {

@avivkeller
Copy link
Member

Left an initial review, but like @Ethan-Arrowood said, it'll take time for a more in depth look

@joyeecheung
Copy link
Member

joyeecheung commented Jan 22, 2026

It's nice to see some momentum in this area, though from a first glance it seems the design has largely overlooked the feedback from real world use cases collected 4 years ago: https://github.com/nodejs/single-executable/blob/main/docs/virtual-file-system-requirements.md - I think it's worth checking that the API satisfies the constraints that users of this feature have provided, to not waste the work that have been done by prior contributors to gather them, or having to reinvent it later (possibly in a breaking manner) to satisfy these requirements from real world use cases.

@codecov
Copy link

codecov bot commented Jan 22, 2026

Codecov Report

❌ Patch coverage is 92.99967% with 635 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.82%. Comparing base (e0928d6) to head (b047092).
⚠️ Report is 26 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/vfs/setup.js 82.25% 223 Missing and 1 partial ⚠️
lib/internal/vfs/providers/memory.js 89.87% 99 Missing and 3 partials ⚠️
lib/internal/vfs/providers/real.js 84.59% 65 Missing ⚠️
lib/internal/vfs/file_system.js 96.16% 53 Missing ⚠️
lib/internal/vfs/watcher.js 92.93% 43 Missing and 3 partials ⚠️
lib/internal/vfs/streams.js 90.61% 29 Missing ⚠️
lib/internal/vfs/stats.js 91.34% 21 Missing and 4 partials ⚠️
lib/internal/vfs/provider.js 96.11% 20 Missing and 4 partials ⚠️
src/node_sea.cc 64.28% 12 Missing and 8 partials ⚠️
lib/internal/fs/cp/cp-sync.js 63.41% 15 Missing ⚠️
... and 9 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61478      +/-   ##
==========================================
+ Coverage   89.68%   89.82%   +0.13%     
==========================================
  Files         676      692      +16     
  Lines      206555   215687    +9132     
  Branches    39552    41259    +1707     
==========================================
+ Hits       185249   193739    +8490     
- Misses      13444    14061     +617     
- Partials     7862     7887      +25     
Files with missing lines Coverage Δ
lib/fs.js 98.54% <100.00%> (+0.35%) ⬆️
lib/internal/bootstrap/realm.js 96.21% <100.00%> (+<0.01%) ⬆️
lib/internal/fs/utils.js 99.68% <100.00%> (+<0.01%) ⬆️
lib/internal/modules/cjs/loader.js 98.20% <100.00%> (+0.05%) ⬆️
lib/internal/modules/esm/get_format.js 94.83% <100.00%> (ø)
lib/internal/modules/esm/load.js 91.47% <100.00%> (ø)
lib/internal/modules/esm/resolve.js 99.03% <100.00%> (-0.01%) ⬇️
lib/internal/modules/esm/translators.js 97.67% <100.00%> (+<0.01%) ⬆️
lib/internal/modules/helpers.js 98.73% <100.00%> (+0.01%) ⬆️
lib/internal/modules/package_json_reader.js 99.72% <100.00%> (+<0.01%) ⬆️
... and 26 more

... and 42 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jimmywarting
Copy link

jimmywarting commented Jan 22, 2026

And why not something like OPFS aka whatwg/fs?

const rootHandle = await navigator.storage.getDirectory()
await rootHandle.getFileHandle('config.json', { create: true })
fs.mount('/app', rootHandle) // to make it work with fs
fs.readFileSync('/app/config.json')

OR

const rootHandle = await navigator.storage.getDirectory()
await rootHandle.getFileHandle('config.json', { create: true })

fs.readFileSync('sandbox:/config.json')

fs.createVirtual seems like something like a competing specification

@mcollina mcollina force-pushed the vfs branch 3 times, most recently from 5e317de to 977cc3d Compare January 23, 2026 08:15
@mcollina
Copy link
Member Author

And why not something like OPFS aka whatwg/fs?

I generally prefer not to interleave with WHATWG specs as much as possible for core functionality (e.g., SEA). In my experience, they tend to perform poorly on our codebase and remove a few degrees of flexibility. (I also don't find much fun in working on them, and I'm way less interested in contributing to that.)

On an implementation side, the core functionality of this feature will be identical (technically, it's missing writes that OPFS supports), as we would need to impact all our internal fs methods anyway.

If this lands, we can certainly iterate on a WHATWG-compatible API for this, but I would not add this to this PR.

@juliangruber
Copy link
Member

Small prior art: https://github.com/juliangruber/subfs

@mcollina mcollina force-pushed the vfs branch 2 times, most recently from 8d711c1 to 73c18cd Compare January 23, 2026 13:19
@Qard
Copy link
Member

Qard commented Jan 23, 2026

I also worked on this a bit on the side recently: Qard@73b8fc6

That is very much in chaotic ideation stage with a bunch of LLM assistance to try some different ideas, but the broader concept I was aiming for was to have a VirtualFileSystem type which would actually implement the entire API surface of the fs module, accepting a Provider type to delegate the internals of the whole cluster of file system types to a singular class managing the entire cluster of fs-related types such that the fs module could actually just be fully converted to:

module.exports = new VirtualFileSystem(new LocalProvider())

I intended for it to be extensible for a bunch of different interesting scenarios, so there's also an S3 provider and a zip file provider there, mainly just to validate that the model can be applied to other varieties of storage systems effectively.

Keep in mind, like I said, the current state is very much just ideation in a branch I pushed up just now to share, but I think there are concepts for extensibility in there that we could consider to enable a whole ecosystem of flexible storage providers. 🙂

Personally, I would hope for something which could provide both read and write access through an abstraction with swappable backends of some variety, this way we could pass around these virtualized file systems like objects and let an ecosystem grow around accepting any generalized virtual file system for its storage backing. I think it'd be very nice for a lot of use cases like file uploads or archive management to be able to just treat them like any other readable and writable file system.

@jimmywarting
Copy link

jimmywarting commented Jan 23, 2026

Personally, I would hope for something which could provide both read and write access through an abstraction with swappable backends of some variety, this way we could pass around these virtualized file systems like objects and let an ecosystem grow around accepting any generalized virtual file system for its storage backing. I think it'd be very nice for a lot of use cases like file uploads or archive management to be able to just treat them like any other readable and writable file system.

just a bit off topic... but this reminds me of why i created this feature request:
Blob.from() for creating virtual Blobs with custom backing storage

Would not lie, it would be cool if NodeJS also provided some type of static Blob.from function to create virtual lazy blobs. could live on fs.blobFrom for now...

example that would only work in NodeJS (based on how it works internally)

const size = 26

const blobPart = BlobFrom({
  size,
  stream (start, end) {
    // can either be sync or async (that resolves to a ReadableStream)
    // return new Response('abcdefghijklmnopqrstuvwxyz'.slice(start, end)).body
    // return new Blob(['abcdefghijklmnopqrstuvwxyz'.slice(start, end)]).stream()
    
    return fetch('https://httpbin.dev/range/' + size, {
      headers: {
        range: `bytes=${start}-${end - 1}`
      }
    }).then(r => r.body)
  }
})

blobPart.text().then(text => {
  console.log('a-z', text)
})

blobPart.slice(-3).text().then(text => {
  console.log('x-z', text)
})

const a = blobPart.slice(0, 6)
a.text().then(text => {
  console.log('a-f', text)
})

const b = a.slice(2, 4)
b.text().then(text => {
  console.log('c-d', text)
})
x-z xyz
a-z abcdefghijklmnopqrstuvwxyz
a-f abcdef
c-d cd

An actual working PoC

(I would not rely on this unless it became officially supported by nodejs core - this is a hack)

const blob = new Blob()
const symbols = Object.getOwnPropertySymbols(blob)
const blobSymbol = symbols.map(s => [s.description, s])
const symbolMap = Object.fromEntries(blobSymbol)
const {
  kHandle,
  kLength,
} = symbolMap

function BlobFrom ({ size, stream }) {
  const blob = new Blob()
  if (size === 0) return blob

  blob[kLength] = size
  blob[kHandle] = {
    span: [0, size],

    getReader () {
      const [start, end] = this.span
      if (start === end) {
        return { pull: cb => cb(0) }
      }

      let reader

      return {
        async pull (cb) {
          reader ??= (await stream(start, end)).getReader()
          const {done, value} = await reader.read()
          cb(done ^ 1, value)
        }
      }
    },

    slice (start, end) {
      const [baseStart] = this.span

      return {
        span: [baseStart + start, baseStart + end],
        getReader: this.getReader,
        slice: this.slice,
      }
    }
  }

  return blob
}

currently problematic to do: new Blob([a, b]), new File([blobPart], 'alphabet.txt', { type: 'text/plain' })

also need to handle properly clone, serialize & deserialize, if this where to be sent of to another worker - then i would transfer a MessageChannel where the worker thread asks main frame to hand back a transferable ReadableStream when it needs to read something.

but there are probably better ways to handle this internally in core with piping data directly to and from different destinations without having to touch the js runtime? - if only getReader could return the reader directly instead of needing to read from the ReadableStream using js?

@Renegade334
Copy link
Member

Should this live behind a CLI flag initially? It might be better to work through #62328 while the API is not exposed by default.

@jasnell
Copy link
Member

jasnell commented Mar 18, 2026

Should this live behind a CLI flag initially?

Honestly... wouldn't hurt.

@mcollina
Copy link
Member Author

mcollina commented Mar 18, 2026

Should this live behind a CLI flag initially? It might be better to work through #62328 while the API is not exposed by default.

The biggest risk of this PR is the patches on the modules' loading side and the fs side. You cannot put them behind a flag. The rest is just a new API with bugs (like many others).

Add 18 feature-focused test files covering bug fixes from both analysis
rounds, and fix lint issues in stats.js and setup.js.

Test files added:
- test-vfs-access.js: access validation and mode enforcement
- test-vfs-buffer-encoding.js: buffer encodings and buffer path args
- test-vfs-copyfile-mode.js: copyFile COPYFILE_EXCL support
- test-vfs-dir-handle.js: Dir double-close and read/close callbacks
- test-vfs-file-url.js: file: URL handling with fileURLToPath
- test-vfs-mkdir-recursive-return.js: mkdirSync recursive return value
- test-vfs-no-auto-mkdir.js: writes/opens don't auto-create parents
- test-vfs-open-flags.js: read-only/write-only/exclusive/numeric flags
- test-vfs-readdir-recursive.js: readdir recursive with names and dirents
- test-vfs-readfile-fd.js: readFileSync with virtual fd
- test-vfs-rename-safety.js: renameSync preserves source on failure
- test-vfs-rm-edge-cases.js: rmSync dir/link edge cases
- test-vfs-stats-bigint.js: BigInt stats support
- test-vfs-stream-options.js: writestream start and stream fd option
- test-vfs-symlink-edge-cases.js: broken/intermediate symlinks
- test-vfs-watch-directory.js: directory and recursive watch
- test-vfs-watchfile.js: unwatchFile cleanup and zero stats
- test-vfs-writefile-flags.js: writeFile/appendFile flag support

Lint fixes:
- stats.js: use BigInt from primordials, add missing JSDoc @returns
- setup.js: consolidate ERR_MODULE_NOT_FOUND import
@ljharb
Copy link
Member

ljharb commented Mar 18, 2026

Could those patches land separately from the flaggable part? That way it’d be much easier to ensure no regressions from either portion.

(i ofc wouldn’t suggest splitting the PR until after it was otherwise philosophically landable)

@mcollina
Copy link
Member Author

@ljharb maybe. But we would be landing code that is not tested, so we’d be in a worse stance?

Note that experimental features are covered by our threat model.

@ljharb
Copy link
Member

ljharb commented Mar 18, 2026

Surely either the patches are meant to be noops in isolation (in which case existing tests should be sufficient - or improved - to assure no breakage) or have independent functionality (which could ship with its own tests)?

@mcollina
Copy link
Member Author

I don’t understand why. Ease of review?

@ljharb
Copy link
Member

ljharb commented Mar 18, 2026

Ease of review, and if there’s any risk in patching core subsystems, that PR could also be released as a patch and then that assumption would be tested, and the VFS functionality could land separately as a minor.

Certainly not required, but it might be prudent for assuring stability.

- Check AbortSignal before VFS fast path in readFile/writeFile/appendFile
- Honor options.flag in readFile/readFileSync on VFS paths
- Route realpath.native/realpathSync.native through VFS handlers
- Implement chmod/chown/utimes/lutimes in MemoryProvider instead of no-ops
- Pass bigint option through VFSStatWatcher to statSync/createZeroStats
- Coerce BigInt positions to Number in MemoryFileHandle read/write
- Track nlink on MemoryEntry, increment on link, decrement on unlink
- Update ctime alongside mtime on content mutations (write/truncate)
- Throw ERR_INVALID_PACKAGE_CONFIG for malformed package.json in VFS
  instead of silently falling through to index.js resolution
- Move writeFile/appendFile options validation (getOptions, parseFileMode,
  validateBoolean) before VFS fast path so invalid options are rejected
- Validate flags with stringToFlags() before VFS check in open/openSync
  so invalid flag values like {} throw ERR_INVALID_ARG_VALUE
- Fix rmdirSync to not follow symlinks (use getEntry with false) so
  symlinks to directories correctly throw ENOTDIR
- Update parent directory mtime/ctime when children are added or removed
  in openSync, mkdirSync, rmdirSync, unlinkSync, linkSync, symlinkSync,
  and renameSync
- Pass bigint option through to VFS statfs handlers and return BigInt
  values when options.bigint is true
Convert VFS callback and promise code paths from calling sync handler
methods to async handlers that return undefined (not handled) or a
Promise (VFS handles it). This removes sync method calls from async
code paths, making VFS safe for custom providers that do real I/O.

Add DRY utilities (vfsRead, vfsOp, vfsOpVoid in setup.js and
vfsResult, vfsVoid in fs.js) to eliminate repeated boilerplate across
~80 handler methods and ~40 fs functions. Add missing promises methods
(chmod, chown, lchown, utimes, lutimes, open, lchmod) to
file_system.js. Fix bugs where chown/lchown/lutimes async handlers
called wrong sync methods. Fix invalid package.json handling to
gracefully fall through in CJS context instead of throwing.
@mcollina
Copy link
Member Author

I’m moving this back to draft. I’m doing some refactoring and simplifying some paths for ease of review. I’ll add a review guide in the PR description and an architecture diagram explaining how all pieces fit together.

Convert FD-based callback functions (close, read, write, fstat,
ftruncate, fdatasync, fsync, fchmod, fchown, futimes, readv, writev)
from sync handler + process.nextTick to async handlers using the
undefined | Promise pattern, matching the approach already used for
path-based operations.

Add async FD handlers to setup.js that call the async methods on
MemoryFileHandle (read, write, stat, truncate, close) instead of
their sync counterparts, avoiding event loop blocking for custom
VFS providers that do real I/O.

Fix vfs.md documentation that was significantly out of date:
- Remove false claim that chmod, chown, truncate, utimes, link,
  fdatasync, fsync have no VFS equivalent (all are implemented)
- Add missing intercepted methods to the fs integration section
  (truncate, link, chmod, chown, lchown, utimes, lutimes, mkdtemp,
  lchmod, cp, statfs, opendir, readv, writev, ftruncate, fchmod,
  fchown, futimes, fdatasync, fsync)
- Shrink "not intercepted" list to just glob/globSync
- Add missing provider.supportsWatch documentation
- Update overlay mode operation routing lists
Security:
- Reject overlapping VFS mounts with clear error
- Add allowWithPermissionModel opt-in for permission model
- RealFSProvider: validate symlink targets, throw EACCES on escape

API compatibility:
- VirtualFileHandle: add no-op stubs and Symbol.asyncDispose
- Intercept fsPromises.open() for VFS paths
- Streams: add bytesRead/pending and bytesWritten/pending
- VirtualDir: add Symbol.asyncDispose/Symbol.dispose
- Stats: use dev=4085 and incrementing ino
- Fix VFSStatWatcher listener signature

Correctness:
- Fix checkClosed to accept syscall parameter
- Fix writeFileSync to use isAppend() for ax/ax+ flags
- Fix dead ternary in hookProcessCwd
- SEAProvider: cache asset sizes, recursive readdir, numeric flags
- Streams: use inherited destroyed, EBADF on null fd

Architecture:
- Cross-VFS rename/copyFile/link throw EXDEV
- Clear package.json caches on unmount
- Convert readFile async handler to use promises

Mock integration:
- Make MockFSContext.vfs a private field with getter
- Fix parentDir root check for Windows portability
- restoreAll() collects errors into AggregateError
- Remove obsolete skipped dynamic content test

Provider fixes:
- MemoryProvider: statSync uses contentProvider for dynamic size
- MemoryProvider: recursive readdir follows symlinks
- RealFSProvider: add watch/watchFile/unwatchFile support

Code quality:
- Cap watcher pending events queue at 1024
- Remove redundant destroyed field from streams
Replace the monolithic test-vfs-followups.js with individual test files:
- test-vfs-overlapping-mounts.js
- test-vfs-promises-open.js
- test-vfs-stream-properties.js
- test-vfs-dir-disposal.js
- test-vfs-stats-ino-dev.js
- test-vfs-append-write.js
- test-vfs-cross-device.js
- test-vfs-readdir-symlink-recursive.js
- test-vfs-package-json-cache.js
- test-vfs-readfile-async.js
@alexweej
Copy link
Contributor

alexweej commented Mar 20, 2026

Have we acknowledged and documented anywhere the limitations of a Node.js-specific VFS? For example, any native addon code, any child processes (written in either Node.js or other languages and runtimes) won't see the same view of the VFS. Which is why by default I advocate for solving these types of problems at the platform layer, i.e. Linux (including "SEA", to be honest).

@Qard
Copy link
Member

Qard commented Mar 20, 2026

For example, any native addon code, any child processes, written in either Node.js or other languages and runtimes, won't see the same view of the VFS.

This is one of the reasons why I was suggesting that rather than this global mount(...) system we instead used a dependency injection approach where APIs can accept a fs-like interface which could be the real fs module or it could be a virtualized one. If we're passing around the VFS as an object it's a lot easier to both control where it is and is not used, and also to ensure it can reach these other places like native code or workers as it would need to be written to handle it explicitly.

@arcanis
Copy link
Contributor

arcanis commented Mar 20, 2026

as it would need to be written to handle it explicitly

Which is why such an hypothetical API wouldn't be used imo.

  • Many packages already rely on fs and would never be updated to support arbitrary virtual systems. And any such util package would "poison" anything that depends on them, preventing them from supporting vfs as well.

  • It's very debatable that having a dependency injection model would incentivize any third party tool to actually use that model - just like the Loaders API is still mostly unsupported by third-party resolvers.

By contrast the transparent vfs has been proven to work in production for the past 6+ years (through Electron first, then more generally by Yarn PnP).

@conartist6

This comment was marked as off-topic.

@Qard
Copy link
Member

Qard commented Mar 20, 2026

  • And any such util package would "poison" anything that depends on them, preventing them from supporting vfs as well.

There's not actually that many packages which actually interact with the file system. I don't think it would actually be as hard as you suggest to guide the ecosystem to adopting a cleaner pattern. To me, giving in to a bad pattern just because it's what the ecosystem currently does is lazy. We do in fact have the ability to drive adoption of new patterns and systems, as evidenced by projects adopting the built-in test framework which was added long after other options had already "won" that use case.

  • It's very debatable that having a dependency injection model would incentivize any third party tool to actually use that model - just like the Loaders API is still mostly unsupported by third-party resolvers.

Adoption of ESM itself lagged significantly for a long time. That in combination with Loaders being a moving target for a very long time further lended to that lack of adoption.

Also, it's fine if not everything supports it. I don't think it's wise to be potentially weakening our security model and increasing the surface area of things which users could break unintentionally in order to extend such functionality to code which was never intended to have such capability in the first place. In my opinion these things should be explicit. Too much magic is harmful.

I recognize that not everyone agrees with me on this though, so I won't block on that point. But I still maintain that I dislike the design decision.

@arcanis
Copy link
Contributor

arcanis commented Mar 20, 2026

To me, giving in to a bad pattern just because it's what the ecosystem currently does is lazy

Which is my point: leaving it up to the ecosystem to adapt for Node.js APIs because we couldn't make a safe design would be lazy. And a bad pattern.

Using tests isn't a good analogy because not only have they marginal use in the wild compared to Jest / Vitest (due to being a recent feature, I'll give you that, although I think it supports what I was saying), it's also not a foundational feature. It's used at the app level, never in transitive packages. You simply don't have this poisonous pattern I raised earlier.

I don't think it's wise to be potentially weakening our security model

How is that weakening it? It's very vague.

@Qard
Copy link
Member

Qard commented Mar 20, 2026

Which is my point: leaving it up to the ecosystem to adapt for Node.js APIs because we couldn't make a safe design would be lazy. And a bad pattern.

But this isn't a safe design, so what we're providing here is a bad pattern. The non-lazy thing to do would be to provide something with better defined boundaries and then put in the work to advocate for the ecosystem to adopt it. Taking the route of just trying to make a thing which fits into how things work presently is the lazy approach.

How is that weakening it? It's very vague.

I've already gone over it in detail earlier in the thread. Any dependency anywhere can add custom mounts which could hijack any fs access anywhere in the entire thread. It could easily hijack things like reading certs, reading config files, etc. Malicious dependencies could easily hijack file access for nefarious purposes.

@arcanis
Copy link
Contributor

arcanis commented Mar 20, 2026

I've already gone over it in detail earlier in the thread. Any dependency anywhere can add custom mounts which could hijack any fs access anywhere in the entire thread.

I saw that, but I also saw mentioned that the Node.js threat model explicitly permits that:

Node.js trusts everything else. Examples include:

  • [...]

  • The code it is asked to run, including JavaScript, WASM and native code, even if said code is dynamically loaded, e.g., all dependencies installed from the npm registry. The code run inherits all the privileges of the execution user.

@Qard
Copy link
Member

Qard commented Mar 20, 2026

Just because our threat model allows it doesn't mean it does not have a measurable impact on security. It just means we don't consider it important enough or feasible to address. But it is definitely a thing which can and probably will be attacked via malicious dependencies.

@joyeecheung
Copy link
Member

joyeecheung commented Mar 20, 2026

I'd agree with @jasnell re. security. It's not in our threat model and it would be worse to create the false belief that there's any assurance for them to not be hijackable, because people have already been hijacking it and it's part of the hidden contract that's widely relied upon by the ecosystem that we cannot break. It's better to have a clear contract than not having contract at all and encourage people to invent their own way of hijacking it.

For reference, see #62012 - everytime we subtly change any internal module loading order such that fs methods are cached internally, we can break popular ecosystem tools like Yarn PnP and a bunch others. Having a clear contract for vfs hooks means they can move on to use that clear contract and we no longer have to worry about these when touching internals. Not having a clear contract just means people will find their own way to hijack it that creates maintenance burden for us due to Hyrum's law.

It could easily hijack things like reading certs, reading config files, etc. Malicious dependencies could easily hijack file access for nefarious purposes.

I do not think that's so easy without explicit integration. Take reading certs for example - we don't read certs through fs, this PR (at least since my lasts review) doesn't force a C++ -> JS callback to go through VFS providers (this would be visible in code, and TBH difficult to get right even if you want to do it because the certificate initialization code can happen before calling into JS is possible, and it can even go off thread), so I don't think it'd allow that unless it's explicitly implemented. We have the same thing for credentials::SafeGetenv v.s. env->env_vars()->Get() - in C++ it's fairly clear the former would not get affected by e.g. worker customizations.

Copy link

@ThanhDodeurOdoo ThanhDodeurOdoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not very familiar with node's internals but here are a few things that seem like potential issues

if (r !== null) {
const fd = r.vfs.openSync(r.normalized, flags, mode);
const vfd = getVirtualFd(fd);
return PromiseResolve(vfd.entry);
Copy link

@ThanhDodeurOdoo ThanhDodeurOdoo Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't that make fs.open() return a virtual file handle object instead of a fd (number)?

edit: and fs.promises.open() returns a virtual handle instead of a FileHandle

Comment on lines +288 to +289
this[kOriginalChdir] = process.chdir;
this[kOriginalCwd] = process.cwd;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we make multiple vfs with virtualCwd: true that can behave badly (like restauring stale refs), could it be explicitly disallowed?


watchFile(vfsPath, options) {
const realPath = this.#resolvePath(vfsPath);
return fs.watchFile(realPath, options, () => {});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VirtualFileSystem.watchFile() forwards the listener but here it's just dropped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fs Issues and PRs related to the fs subsystem / file system. lib / src Issues and PRs related to general changes in the lib or src directory. module Issues and PRs related to the module subsystem. needs-benchmark-ci PR that need a benchmark CI run. needs-ci PRs that need a full CI run. notable-change PRs with changes that should be highlighted in changelogs. semver-minor PRs that contain new features and should be released in the next minor version. test_runner Issues and PRs related to the test runner subsystem. tsc-agenda Issues and PRs to discuss during the meetings of the TSC.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement VFS (Virtual File System) Hooks for Single Executable Applications